Vaya más allá de las pruebas tradicionales basadas en ejemplos. Esta guía completa explora las pruebas basadas en propiedades en JavaScript usando fast-check, ayudándole a encontrar más errores con menos código.
Más Allá de los Ejemplos: Una Inmersión Profunda en las Pruebas Basadas en Propiedades en JavaScript
Como desarrolladores de software, pasamos una cantidad significativa de tiempo escribiendo pruebas. Elaboramos meticulosamente pruebas unitarias, pruebas de integración y pruebas de extremo a extremo para asegurar que nuestras aplicaciones sean robustas, fiables y libres de regresiones. El paradigma dominante para esto son las pruebas basadas en ejemplos. Pensamos en una entrada específica y afirmamos una salida específica. La entrada `[1, 2, 3]` debería producir la salida `6`. La entrada `"hello"` debería convertirse en `"HELLO"`. Pero este enfoque tiene una debilidad silenciosa y acechante: nuestra propia imaginación.
¿Qué pasa si olvidas probar con un array vacío? ¿Un número negativo? ¿Una cadena que contiene caracteres Unicode? ¿Un objeto profundamente anidado? Cada caso límite omitido es un error potencial esperando a suceder. Aquí es donde entran en escena las Pruebas Basadas en Propiedades (PBT), ofreciendo un poderoso cambio de paradigma que nos ayuda a construir software con más confianza y resiliencia.
Esta guía completa te llevará a través del mundo de las pruebas basadas en propiedades en JavaScript. Exploraremos qué son, por qué son tan efectivas y cómo puedes implementarlas en tus proyectos hoy mismo utilizando la popular biblioteca `fast-check`.
Las Limitaciones de las Pruebas Tradicionales Basadas en Ejemplos
Consideremos una función simple que ordena un array de números. Usando un framework popular como Jest o Vitest, nuestra prueba podría verse así:
// Una función de ordenamiento simple (y un poco ingenua)
function sortNumbers(arr) {
return [...arr].sort((a, b) => a - b);
}
// Una prueba típica basada en ejemplos
test('sortNumbers debería ordenar correctamente un array simple', () => {
const inputArray = [3, 1, 4, 1, 5, 9];
const expectedArray = [1, 1, 3, 4, 5, 9];
expect(sortNumbers(inputArray)).toEqual(expectedArray);
});
Esta prueba pasa. Podríamos añadir algunos bloques `it` o `test` más:
- Un array que ya está ordenado.
- Un array con números negativos.
- Un array con un cero.
- Un array vacío.
- Un array con números duplicados (que ya cubrimos).
Nos sentimos bien. Hemos cubierto lo básico. ¿Pero qué nos hemos perdido? ¿Qué hay de `[-0, 0]`? ¿Qué hay de `[Infinity, -Infinity]`? ¿Qué hay de un array muy grande que podría alcanzar límites de rendimiento o extrañas optimizaciones del motor de JavaScript? El problema fundamental es que estamos seleccionando los datos manualmente. Nuestras pruebas son tan buenas como los ejemplos que podemos concebir, y los humanos somos notoriamente malos para imaginar todas las formas extrañas y maravillosas en que los datos pueden estructurarse.
Las pruebas basadas en ejemplos validan que tu código funciona para unos pocos escenarios seleccionados a mano. Las pruebas basadas en propiedades validan que tu código funciona para clases enteras de entradas.
¿Qué son las Pruebas Basadas en Propiedades? Un Cambio de Paradigma
Las pruebas basadas en propiedades le dan la vuelta al guion. En lugar de afirmar que una entrada específica produce una salida específica, defines una propiedad general de tu código que debería mantenerse para cualquier entrada válida. El framework de pruebas genera entonces cientos o miles de entradas aleatorias para intentar demostrar que tu propiedad es incorrecta.
Una "propiedad" es una invariante, una regla de alto nivel sobre el comportamiento de tu función. Para nuestra función `sortNumbers`, algunas propiedades podrían ser:
- Idempotencia: Ordenar un array ya ordenado no debería cambiarlo. `sortNumbers(sortNumbers(arr))` debería ser lo mismo que `sortNumbers(arr)`.
- Invariancia de Longitud: El array ordenado debe tener la misma longitud que el array original.
- Invariancia de Contenido: El array ordenado debe contener exactamente los mismos elementos que el array original, solo que en un orden diferente.
- Orden: Para cualquier par de elementos adyacentes en el array ordenado, `sorted[i] <= sorted[i+1]`.
Este enfoque te hace pasar de pensar en ejemplos individuales a pensar en el contrato fundamental de tu código. Este cambio de mentalidad es increíblemente valioso para diseñar APIs mejores y más predecibles.
Los Componentes Centrales de PBT
Un framework de pruebas basadas en propiedades típicamente tiene dos componentes clave:
- Generadores (o Arbitrarios): Son responsables de producir una amplia gama de datos aleatorios según tipos especificados (enteros, cadenas, arrays de objetos, etc.). Son lo suficientemente inteligentes como para generar no solo datos de "camino feliz", sino también casos límite complicados como cadenas vacías, `NaN`, `Infinity` y más.
- Reducción (Shrinking): Este es el ingrediente mágico. Cuando el framework encuentra una entrada que falsifica tu propiedad (es decir, causa un fallo en la prueba), no solo informa de la entrada grande y aleatoria. En su lugar, intenta sistemáticamente encontrar la entrada más pequeña y simple que todavía causa el fallo. Esto hace que la depuración sea exponencialmente más fácil.
Primeros Pasos: Implementando PBT con `fast-check`
Aunque hay varias bibliotecas de PBT en el ecosistema de JavaScript, `fast-check` es una opción madura, potente y bien mantenida. Se integra perfectamente con frameworks de pruebas populares como Jest, Vitest, Mocha y Jasmine.
Instalación y Configuración
Primero, añade `fast-check` a las dependencias de desarrollo de tu proyecto. Asumiremos que estás usando un ejecutor de pruebas como Jest.
npm install --save-dev fast-check jest
# o
yarn add --dev fast-check jest
# o
pnpm add -D fast-check jest
Tu Primera Prueba Basada en Propiedades
Reescribamos nuestra prueba de `sortNumbers` usando `fast-check`. Probaremos la propiedad de "orden" que definimos anteriormente: cada elemento debe ser menor o igual que el que le sigue.
import * as fc from 'fast-check';
// La misma función de antes
function sortNumbers(arr) {
return [...arr].sort((a, b) => a - b);
}
test('la salida de sortNumbers debería ser un array ordenado', () => {
// 1. Describe la propiedad
fc.assert(
// 2. Define los arbitrarios (generadores de entrada)
fc.property(fc.array(fc.integer()), (data) => {
// `data` es un array de enteros generado aleatoriamente
const sorted = sortNumbers(data);
// 3. Define el predicado (la propiedad a verificar)
for (let i = 0; i < sorted.length - 1; ++i) {
if (sorted[i] > sorted[i + 1]) {
return false; // La propiedad es falsificada
}
}
return true; // La propiedad se cumple para esta entrada
})
);
});
test('ordenar no debería cambiar la longitud del array', () => {
fc.assert(
fc.property(fc.array(fc.float()), (data) => {
const sorted = sortNumbers(data);
return sorted.length === data.length;
})
);
});
Desglosemos esto:
- `fc.assert()`: Este es el ejecutor. Ejecutará tu verificación de propiedad muchas veces (100 por defecto).
- `fc.property()`: Esto define la propiedad en sí. Toma uno o más arbitrarios como argumentos, seguido de una función de predicado.
- `fc.array(fc.integer())`: Este es nuestro arbitrario. Le dice a `fast-check` que genere un array (`fc.array`) de enteros (`fc.integer()`). `fast-check` generará automáticamente arrays de diferentes longitudes, con diferentes valores enteros (positivos, negativos, cero, etc.).
- El Predicado: La función anónima `(data) => { ... }` es donde reside nuestra lógica. Recibe los datos generados aleatoriamente y debe devolver `true` si la propiedad se cumple o `false` si se viola. `fast-check` también admite funciones de predicado que lanzan un error en caso de fallo, lo que se integra muy bien con las aserciones `expect` de Jest.
Ahora, en lugar de una prueba con un array elegido a mano, tenemos una prueba que verifica nuestra lógica de ordenación contra 100 arrays diferentes, generados automáticamente, cada vez que ejecutamos nuestra suite. Hemos aumentado masivamente nuestra cobertura de pruebas con solo unas pocas líneas de código.
Explorando Arbitrarios: Generando los Datos Correctos
El poder de PBT reside en su capacidad para generar datos diversos y desafiantes. `fast-check` proporciona un rico conjunto de arbitrarios para cubrir casi cualquier estructura de datos que puedas imaginar.
Arbitrarios Básicos
Estos son los componentes básicos para la generación de tus datos.
- `fc.integer()`, `fc.float()`, `fc.bigInt()`: Para números. Pueden ser restringidos, ej., `fc.integer({ min: 0, max: 100 })`.
- `fc.string()`, `fc.asciiString()`, `fc.unicodeString()`: Para cadenas de varios conjuntos de caracteres.
- `fc.boolean()`: Para `true` o `false`.
- `fc.constant(value)`: Siempre devuelve el mismo valor. Útil para mezclar con `fc.oneof`.
- `fc.constantFrom(val1, val2, ...)`: Devuelve uno de los valores constantes proporcionados.
Arbitrarios Complejos y Compuestos
Puedes combinar arbitrarios básicos para crear estructuras de datos complejas.
- `fc.array(arbitrary, constraints)`: Genera un array de elementos creados por el arbitrario proporcionado. Puedes restringir la `minLength` y `maxLength`.
- `fc.tuple(arb1, arb2, ...)`: Genera un array de longitud fija donde cada elemento tiene un tipo específico y diferente.
- `fc.object(shape)`: Genera objetos con una estructura definida. Ejemplo: `fc.object({ id: fc.uuidV(4), name: fc.string() })`.
- `fc.oneof(arb1, arb2, ...)`: Genera un valor de cualquiera de los arbitrarios proporcionados. Esto es excelente para probar funciones que manejan múltiples tipos de datos (ej., `string | number`).
- `fc.record({ key: arb, value: arb })`: Genera objetos para ser usados como diccionarios o mapas, donde las claves y los valores son generados a partir de arbitrarios.
Creando Arbitrarios Personalizados con `map` y `chain`
A veces necesitas datos que no se ajustan a una forma estándar. `fast-check` te permite crear tus propios arbitrarios transformando los existentes.
Usando `.map()`
El método `.map()` transforma la salida de un arbitrario en otra cosa. Por ejemplo, creemos un arbitrario que genere cadenas no vacías.
const nonEmptyStringArb = fc.string({ minLength: 1 });
// O, transformando un array de caracteres
const nonAStringArb = fc.array(fc.char().filter(c => c !== 'a'))
.map(chars => chars.join(''));
Usando `.chain()`
El método `.chain()` es más potente. Te permite crear un nuevo arbitrario basado en el valor generado de uno anterior. Esto es esencial para crear datos correlacionados.
Imagina que necesitas generar un array y luego un índice válido para ese mismo array. No puedes hacer esto con dos arbitrarios separados, ya que el índice podría estar fuera de los límites. `.chain()` resuelve esto perfectamente.
// Genera un array y un índice válido dentro de él
const arrayAndValidIndexArb = fc.array(fc.anything()).chain(arr => {
// Basado en el array generado `arr`, crea un nuevo arbitrario para el índice
const indexArb = fc.integer({ min: 0, max: arr.length - 1 });
// Devuelve una tupla del array y el índice generado
return fc.tuple(fc.constant(arr), indexArb);
});
// Uso en una prueba
test('cortar en un índice válido debería funcionar', () => {
fc.assert(
fc.property(arrayAndValidIndexArb, ([arr, index]) => {
// Se garantiza que tanto `arr` como `index` son compatibles
const sliced = arr.slice(0, index);
expect(sliced.length).toBe(index);
})
);
});
El Poder de la Reducción (Shrinking): Depuración Simplificada
La característica más convincente de las pruebas basadas en propiedades es la reducción. Para verlo en acción, creemos una función deliberadamente defectuosa.
// Esta función falla si el array de entrada contiene el número 42
function sumWithoutBug(arr) {
if (arr.includes(42)) {
throw new Error('¡Este número no está permitido!');
}
return arr.reduce((acc, val) => acc + val, 0);
}
test('sumWithoutBug debería sumar números', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (data) => {
sumWithoutBug(data);
})
);
});
Cuando ejecutes esta prueba, `fast-check` casi con seguridad encontrará un caso fallido. Pero no informará del primer array aleatorio que encontró, que podría ser algo como `[-1024, 500, 42, 987, -2000]`. Un informe de fallo como ese no es muy útil. Tendrías que inspeccionarlo manualmente para encontrar el problemático `42`.
En su lugar, el reductor de `fast-check` entrará en acción. Verá el fallo y comenzará a simplificar la entrada:
- ¿Puedo quitar un elemento? Intenta `[500, 42, 987, -2000]`. Sigue fallando. Bien.
- ¿Puedo quitar otro? Intenta `[42, 987, -2000]`. Sigue fallando.
- ...y así sucesivamente, hasta que no pueda quitar más elementos sin que la prueba pase.
- También intentará hacer los números más pequeños. ¿Puede `42` ser `0`? No, la prueba pasa. ¿Puede ser `41`? La prueba pasa. Lo va acotando.
El informe de error final se verá algo así:
Error: Property failed after 15 tests
{ seed: 12345678, path: "14", endOnFailure: true }
Counterexample: [[42]]
Shrunk 5 time(s)
Got error: ¡Este número no está permitido!
Te dice la entrada exacta y mínima que causó el fallo: un array que contiene solo el número `[42]`. Esto te señala inmediatamente el origen del error, ahorrándote un tiempo y esfuerzo inmenso en la depuración.
Estrategias Prácticas de PBT y Ejemplos del Mundo Real
PBT no es solo para funciones matemáticas. Es una herramienta versátil que se puede aplicar a muchas áreas del desarrollo de software.
Propiedad: Funciones Inversas
Si tienes una función que codifica datos y otra que los decodifica, son inversas entre sí. Una gran propiedad para probar es que decodificar un valor codificado siempre debería devolver el valor original.
// `encode` y `decode` podrían ser para base64, componentes de URI o serialización personalizada
function encode(obj) { return JSON.stringify(obj); }
function decode(str) { return JSON.parse(str); }
test('decode(encode(x)) debería ser igual a x', () => {
// `fc.jsonValue()` genera cualquier valor JSON válido: cadenas, números, objetos, arrays
fc.assert(
fc.property(fc.jsonValue(), (originalValue) => {
const encoded = encode(originalValue);
const decoded = decode(encoded);
expect(decoded).toEqual(originalValue);
})
);
});
Propiedad: Idempotencia
Una operación es idempotente si aplicarla varias veces tiene el mismo efecto que aplicarla una vez. `f(f(x)) === f(x)`. Esta es una propiedad crucial para cosas como funciones de limpieza de datos o endpoints `DELETE` en una API REST.
// Una función que elimina espacios en blanco al principio/final y colapsa espacios múltiples
function normalizeWhitespace(text) {
return text.trim().replace(/\s+/g, ' ');
}
test('normalizeWhitespace debería ser idempotente', () => {
fc.assert(
fc.property(fc.string(), (originalString) => {
const once = normalizeWhitespace(originalString);
const twice = normalizeWhitespace(once);
expect(twice).toBe(once);
})
);
});
Propiedad: Pruebas Basadas en Estado (o Modelo)
Esta es una técnica más avanzada pero increíblemente poderosa para probar sistemas con estado interno, como un componente de UI, un carrito de compras o una máquina de estados. La idea es crear un modelo de software simple de tu sistema y una serie de comandos que se pueden ejecutar tanto en tu modelo como en la implementación real. La propiedad es que el estado del modelo y el estado del sistema real siempre deben coincidir.
`fast-check` proporciona `fc.commands` para este propósito. Modelemos un contador simple:
// La implementación real
class Counter {
constructor() { this.count = 0; }
increment() { this.count++; }
decrement() { this.count--; }
get() { return this.count; }
}
// Los comandos para fast-check
const incrementCmd = fc.command(
// check: una función para verificar si el comando se puede ejecutar en el modelo
(model) => true,
// run: una función para ejecutar el comando tanto en el modelo como en el sistema real
(model, real) => {
model.count++;
real.increment();
expect(real.get()).toBe(model.count);
}
);
const decrementCmd = fc.command(
(model) => true,
(model, real) => {
model.count--;
real.decrement();
expect(real.get()).toBe(model.count);
}
);
test('Counter debería comportarse según el modelo', () => {
fc.assert(
fc.property(fc.commands([incrementCmd, decrementCmd]), (cmds) => {
const model = { count: 0 };
const real = new Counter();
fc.modelRun(() => ({ model, real }), cmds);
})
);
});
En esta prueba, `fast-check` generará una secuencia aleatoria de comandos `increment` y `decrement`, los ejecutará tanto en nuestro modelo de objeto simple como en la clase `Counter` real, y se asegurará de que nunca diverjan. Esto puede descubrir errores sutiles en lógicas con estado complejas que serían casi imposibles de encontrar con pruebas basadas en ejemplos.
Cuándo NO Usar Pruebas Basadas en Propiedades
PBT es una adición poderosa a tu conjunto de herramientas de prueba, pero no es un reemplazo para todas las demás formas de prueba. No es una bala de plata.
Las pruebas basadas en ejemplos suelen ser mejores cuando:
- Se prueban reglas de negocio específicas y conocidas. Si un cálculo de impuestos debe producir exactamente `$10.53` para una entrada específica, una simple prueba basada en ejemplos es más clara y directa. Esta es una prueba de regresión para un requisito conocido.
- La "propiedad" es simplemente "la entrada X produce la salida Y". Si no hay una regla generalizable de nivel superior sobre el comportamiento de la función, forzar una prueba basada en propiedades puede ser más complejo de lo que vale la pena.
- Se prueba la corrección visual de las interfaces de usuario. Aunque puedes probar la lógica de estado de un componente de UI con PBT, verificar un diseño visual o estilo específico se maneja mejor con pruebas de instantáneas (snapshot testing) o herramientas de regresión visual.
La estrategia más efectiva es un enfoque híbrido. Usa pruebas basadas en propiedades para someter a estrés tus algoritmos, transformaciones de datos y lógica con estado contra un universo de posibilidades. Usa pruebas tradicionales basadas en ejemplos para fijar requisitos de negocio específicos y críticos y prevenir regresiones en errores conocidos.
Conclusión: Piensa en Propiedades, No Solo en Ejemplos
Las pruebas basadas en propiedades fomentan un cambio profundo en cómo pensamos sobre la corrección. Nos obliga a dar un paso atrás de los ejemplos individuales y considerar los principios y contratos fundamentales que nuestro código debería mantener. Al hacerlo, podemos:
- Descubrir casos límite sorprendentes para los que nunca hubiéramos pensado en escribir pruebas.
- Ganar una confianza mucho mayor en la robustez de nuestro código.
- Escribir pruebas más expresivas que documenten el comportamiento de nuestro sistema en lugar de solo su salida para unas pocas entradas.
- Reducir drásticamente el tiempo de depuración gracias al poder de la reducción.
Adoptar las pruebas basadas en propiedades puede parecer poco familiar al principio, pero la inversión vale la pena. Comienza de a poco. Elige una función pura en tu código base —una que maneje la transformación de datos o un cálculo complejo— e intenta definir una propiedad para ella. Añade una prueba basada en propiedades a tu próximo proyecto. Cuando seas testigo de cómo encuentra su primer error no trivial, estarás convencido de su poder para construir software mejor y más fiable para una audiencia global.
Recursos Adicionales
- Documentación Oficial de fast-check
- Understanding Property-Based Testing por Scott Wlaschin (una introducción clásica e independiente del lenguaje)